Domine o design orientado a domínio em JavaScript. Aprenda o Padrão de Entidade de Módulo para construir aplicações escaláveis, testáveis e de fácil manutenção com modelos de objetos de domínio robustos.
Padrões de Entidade de Módulo JavaScript: Um Mergulho Profundo na Modelagem de Objetos de Domínio
No mundo do desenvolvimento de software, especialmente no ecossistema dinâmico e em constante evolução do JavaScript, muitas vezes priorizamos velocidade, frameworks e funcionalidades. Construímos interfaces de usuário complexas, conectamo-nos a inúmeras APIs e implantamos aplicações em um ritmo vertiginoso. Mas, nessa correria, às vezes negligenciamos o cerne de nossa aplicação: o domínio de negócios. Isso pode levar ao que é frequentemente chamado de "Big Ball of Mud" (Grande Bola de Lama) – um sistema onde a lógica de negócios está espalhada, os dados são não estruturados e fazer uma simples alteração pode desencadear uma cascata de bugs imprevistos.
É aí que entra a Modelagem de Objetos de Domínio. É a prática de criar um modelo rico e expressivo do espaço de problemas em que você está trabalhando. E em JavaScript, o Padrão de Entidade de Módulo é uma maneira poderosa, elegante e agnóstica a frameworks para alcançar isso. Este guia completo o levará pela teoria, prática e benefícios deste padrão, capacitando-o a construir aplicações mais robustas, escaláveis e fáceis de manter.
O que é Modelagem de Objetos de Domínio?
Antes de mergulharmos no padrão em si, vamos esclarecer nossos termos. É crucial distinguir este conceito do Document Object Model (DOM) do navegador.
- Domínio: Em software, o 'domínio' é a área de assunto específica à qual o negócio do usuário pertence. Para uma aplicação de comércio eletrônico, o domínio inclui conceitos como Produtos, Clientes, Pedidos e Pagamentos. Para uma plataforma de mídia social, inclui Usuários, Postagens, Comentários e Curtidas.
- Modelagem de Objetos de Domínio: Este é o processo de criação de um modelo de software que representa as entidades, seus comportamentos e seus relacionamentos dentro desse domínio de negócios. Trata-se de traduzir conceitos do mundo real em código.
Um bom modelo de domínio não é apenas uma coleção de contêineres de dados. É uma representação viva das suas regras de negócios. Um objeto Pedido não deve apenas conter uma lista de itens; ele deve saber como calcular seu total, como adicionar um novo item e se ele pode ser cancelado. Essa encapsulação de dados e comportamento é a chave para construir um núcleo de aplicação resiliente.
O Problema Comum: Anarquia na Camada "Modelo"
Em muitas aplicações JavaScript, especialmente aquelas que crescem organicamente, a camada 'modelo' é frequentemente um pensamento posterior. Vemos frequentemente este antipadrão:
// Em algum lugar em um controlador de API ou serviço...
async function createUser(req, res) {
const { email, password, firstName, lastName } = req.body;
// Lógica de negócios e validação estão espalhadas aqui
if (!email || !email.includes('@')) {
return res.status(400).send({ error: 'Um email válido é necessário.' });
}
if (!password || password.length < 8) {
return res.status(400).send({ error: 'A senha deve ter pelo menos 8 caracteres.' });
}
const user = {
email: email.toLowerCase(),
password: await hashPassword(password), // Alguma função utilitária
fullName: `${firstName} ${lastName}`, // Lógica para dados derivados está aqui
createdAt: new Date()
};
// Agora, o que é `user`? É apenas um objeto simples.
// Nada impede outro desenvolvedor de fazer isso mais tarde:
// user.email = 'an-invalid-email';
// user.password = 'short';
await db.users.insert(user);
res.status(201).send(user);
}
Essa abordagem apresenta vários problemas críticos:
- Sem Fonte Única de Verdade: As regras para o que constitui um 'usuário' válido são definidas dentro deste único controlador. E se outra parte do sistema precisar criar um usuário? Você copia e cola a lógica? Isso leva à inconsistência e bugs.
- Modelo de Domínio Anêmico: O objeto `user` é apenas um "contêiner" "burro" de dados. Ele não tem comportamento e não tem autoconsciência. Toda a lógica que opera nele vive externamente.
- Baixa Coesão: A lógica para criar o nome completo de um usuário está misturada com o tratamento de requisição/resposta da API e o hashing de senha.
- Difícil de Testar: Para testar a lógica de criação de usuário, você precisa simular requisições e respostas HTTP, bancos de dados e funções de hashing. Você não pode simplesmente testar o conceito de 'usuário' isoladamente.
- Contratos Implícitos: O restante da aplicação tem que 'assumir' que qualquer objeto que represente um usuário tenha uma determinada forma e que seus dados sejam válidos. Não há garantias.
A Solução: O Padrão de Entidade de Módulo JavaScript
O Padrão de Entidade de Módulo aborda esses problemas usando um módulo JavaScript padrão (um arquivo) para definir tudo sobre um único conceito de domínio. Este módulo se torna a fonte definitiva de verdade para essa entidade.
Uma Entidade de Módulo normalmente expõe uma função fábrica. Essa função é responsável por criar uma instância válida da entidade. O objeto que ela retorna não é apenas dados; é um objeto de domínio rico que encapsula seus próprios dados, validação e lógica de negócios.
Principais Características de uma Entidade de Módulo
- Encapsulamento: Agrupa dados e as funções que operam nesses dados.
- Validação na Fronteira: Garante que seja impossível criar uma entidade inválida. Ela protege seu próprio estado.
- API Clara: Expõe um conjunto limpo e intencional de funções (uma API pública) para interagir com a entidade, enquanto oculta detalhes de implementação internos.
- Imutabilidade: Geralmente produz objetos imutáveis ou somente leitura para evitar alterações acidentais de estado e garantir comportamento previsível.
- Portabilidade: Não tem dependências de frameworks (como Express, React) ou sistemas externos (como bancos de dados, APIs). É lógica de negócios pura.
Componentes Principais de uma Entidade de Módulo
Vamos reconstruir nosso conceito de `User` usando este padrão. Criaremos um arquivo, `user.js` (ou `user.ts` para usuários TypeScript), e o construiremos passo a passo.
1. A Função Fábrica: Seu Construtor de Objetos
Em vez de classes, usaremos uma função fábrica (por exemplo, `buildUser`). As fábricas oferecem grande flexibilidade, evitam a luta com a palavra-chave `this` e tornam o estado privado e o encapsulamento mais naturais em JavaScript.
Nosso objetivo é criar uma função que receba dados brutos e retorne um objeto User bem formado e confiável.
// arquivo: /domain/user.js
export default function buildMakeUser() {
// Esta função interna é a fábrica real.
// Ela tem acesso a quaisquer dependências passadas para buildMakeUser, se necessário.
return function makeUser({
id = generateId(), // Vamos assumir uma função para gerar um ID único
firstName,
lastName,
email,
passwordHash,
createdAt = new Date()
}) {
// ... validação e lógica irão aqui ...
const user = {
getId: () => id,
getFirstName: () => firstName,
getLastName: () => lastName,
getEmail: () => email,
getPasswordHash: () => passwordHash,
getCreatedAt: () => createdAt
};
// Usando Object.freeze para tornar o objeto imutável.
return Object.freeze(user);
}
}
Note algumas coisas aqui. Estamos usando uma função que retorna uma função (uma função de ordem superior). Este é um padrão poderoso para injetar dependências, como um gerador de ID único ou uma biblioteca de validação, sem acoplar a entidade a uma implementação específica. Por enquanto, vamos mantê-lo simples.
2. Validação de Dados: O Guardião no Portão
Uma entidade deve proteger sua própria integridade. Deve ser impossível criar um `User` em um estado inválido. Adicionamos validação diretamente na função fábrica. Se os dados forem inválidos, a fábrica deve lançar um erro, declarando claramente o que está errado.
// arquivo: /domain/user.js
export default function buildMakeUser({ Id, isValidEmail, hashPassword }) {
return function makeUser({
id = Id.makeId(),
firstName,
lastName,
email,
password, // Agora recebemos uma senha simples e a tratamos internamente
createdAt = new Date()
}) {
if (!Id.isValidId(id)) {
throw new Error('O usuário deve ter um id válido.');
}
if (!firstName || firstName.length < 2) {
throw new Error('O primeiro nome deve ter pelo menos 2 caracteres.');
}
if (!lastName || lastName.length < 2) {
throw new Error('O sobrenome deve ter pelo menos 2 caracteres.');
}
if (!email || !isValidEmail(email)) {
throw new Error('O usuário deve ter um endereço de email válido.');
}
if (!password || password.length < 8) {
throw new Error('A senha deve ter pelo menos 8 caracteres.');
}
// Normalização e transformação de dados acontecem aqui
const passwordHash = hashPassword(password);
const normalizedEmail = email.toLowerCase();
return Object.freeze({
getId: () => id,
getFirstName: () => firstName,
getLastName: () => lastName,
getEmail: () => normalizedEmail,
getPasswordHash: () => passwordHash,
getCreatedAt: () => createdAt
});
}
}
Agora, qualquer parte do nosso sistema que queira criar um `User` deve passar por esta fábrica. Obtemos validação garantida todas as vezes. Também encapsulamos a lógica de hashing da senha e normalização do endereço de e-mail. O resto da aplicação não precisa saber ou se importar com esses detalhes.
3. Lógica de Negócios: Encapsulando Comportamento
Nosso objeto `User` ainda está um pouco anêmico. Ele contém dados, mas não faz nada. Vamos adicionar comportamento – métodos que representam ações específicas do domínio.
// ... dentro da função makeUser ...
if (!password || password.length < 8) {
// ...
}
const passwordHash = hashPassword(password);
const normalizedEmail = email.toLowerCase();
return Object.freeze({
getId: () => id,
getFirstName: () => firstName,
getLastName: () => lastName,
getEmail: () => normalizedEmail,
getPasswordHash: () => passwordHash,
getCreatedAt: () => createdAt,
// Lógica de Negócios / Comportamento
getFullName: () => `${firstName} ${lastName}`,
// Um método que descreve uma regra de negócios
canVote: () => {
// Em alguns países, a idade para votar é 18. Esta é uma regra de negócios.
// Vamos supor que temos uma propriedade dateOfBirth.
const age = calculateAge(dateOfBirth);
return age >= 18;
}
});
// ...
A lógica `getFullName` não está mais espalhada em algum controlador aleatório; ela pertence à própria entidade `User`. Qualquer pessoa com um objeto `User` pode agora obter confiavelmente o nome completo chamando `user.getFullName()`. A lógica é definida uma vez, em um único lugar.
Construindo um Exemplo Prático: Um Sistema Simples de Comércio Eletrônico
Vamos aplicar este padrão a um domínio mais interconectado. Modelaremos um `Product` (Produto), um `OrderItem` (Item de Pedido) e um `Order` (Pedido).
1. Modelando a Entidade `Product`
Um produto tem um nome, um preço e algumas informações de estoque. Deve ter um nome e seu preço não pode ser negativo.
// arquivo: /domain/product.js
export default function buildMakeProduct({ Id }) {
return function makeProduct({
id = Id.makeId(),
name,
description,
price,
stock = 0
}) {
if (!Id.isValidId(id)) {
throw new Error('O produto deve ter um ID válido.');
}
if (!name || name.trim().length < 2) {
throw new Error('O nome do produto deve ter pelo menos 2 caracteres.');
}
if (isNaN(price) || price <= 0) {
throw new Error('O produto deve ter um preço superior a zero.');
}
if (isNaN(stock) || stock < 0) {
throw new Error('O estoque deve ser um número não negativo.');
}
return Object.freeze({
getId: () => id,
getName: () => name,
getDescription: () => description,
getPrice: () => price,
getStock: () => stock,
// Lógica de negócios
isAvailable: () => stock > 0,
// Um método que modifica o estado retornando uma nova instância
reduceStock: (amount) => {
if (amount > stock) {
throw new Error('Estoque insuficiente disponível.');
}
// Retorna um NOVO objeto de produto com o estoque atualizado
return makeProduct({ id, name, description, price, stock: stock - amount });
}
});
}
}
Observe o método `reduceStock`. Este é um conceito crucial relacionado à imutabilidade. Em vez de alterar a propriedade `stock` no objeto existente, ele retorna uma nova instância de `Product` com o valor atualizado. Isso torna as alterações de estado explícitas e previsíveis.
2. Modelando a Entidade `Order` (A Raiz do Agregado)
Um `Order` é mais complexo. É o que o Domain-Driven Design (DDD) chama de "Raiz do Agregado". É uma entidade que gerencia outros objetos menores dentro de seu limite. Um `Order` contém uma lista de `OrderItem`s. Você não adiciona um produto diretamente a um pedido; você adiciona um `OrderItem` que contém um produto e uma quantidade.
// arquivo: /domain/order.js
export const ORDER_STATUS = {
PENDING: 'PENDING',
PAID: 'PAID',
SHIPPED: 'SHIPPED',
CANCELLED: 'CANCELLED'
};
export default function buildMakeOrder({ Id, validateOrderItem }) {
return function makeOrder({
id = Id.makeId(),
customerId,
items = [],
status = ORDER_STATUS.PENDING,
createdAt = new Date()
}) {
if (!Id.isValidId(id)) {
throw new Error('O pedido deve ter um ID válido.');
}
if (!customerId) {
throw new Error('O pedido deve ter um ID de cliente.');
}
let orderItems = [...items]; // Cria uma cópia privada para gerenciar
return Object.freeze({
getId: () => id,
getCustomerId: () => customerId,
getItems: () => [...orderItems], // Retorna uma cópia para evitar modificação externa
getStatus: () => status,
getCreatedAt: () => createdAt,
// Lógica de Negócios
calculateTotal: () => {
return orderItems.reduce((total, item) => {
return total + (item.getPrice() * item.getQuantity());
}, 0);
},
addItem: (item) => {
// validateOrderItem é uma função que garante que o item seja uma entidade OrderItem válida
validateOrderItem(item);
// Regra de negócios: impedir a adição de duplicatas, apenas aumentar a quantidade
const existingItemIndex = orderItems.findIndex(i => i.getProductId() === item.getProductId());
if (existingItemIndex > -1) {
const newQuantity = orderItems[existingItemIndex].getQuantity() + item.getQuantity();
// Aqui você atualizaria a quantidade no item existente
// (Isso requer que os itens sejam mutáveis ou tenham um método de atualização)
} else {
orderItems.push(item);
}
},
markPaid: () => {
if (status !== ORDER_STATUS.PENDING) {
throw new Error('Apenas pedidos pendentes podem ser marcados como pagos.');
}
// Retorna uma nova instância de Order com o status atualizado
return makeOrder({ id, customerId, items: orderItems, status: ORDER_STATUS.PAID, createdAt });
}
});
}
}
Esta entidade `Order` agora impõe regras de negócios complexas:
- Ela gerencia sua própria lista de itens.
- Ela sabe como calcular seu próprio total.
- Ela impõe transições de estado (por exemplo, você só pode marcar um pedido `PENDING` como `PAID`).
Padrões e Considerações Avançadas
Imutabilidade: O Pilar da Previsibilidade
Temos falado sobre imutabilidade. Por que ela é tão importante? Quando os objetos são imutáveis, você pode passá-los por sua aplicação sem medo de que alguma função distante altere seu estado inesperadamente. Isso elimina uma classe inteira de bugs e torna o fluxo de dados de sua aplicação muito mais fácil de raciocinar.
Object.freeze() fornece um congelamento raso. Para entidades com objetos ou arrays aninhados (como nosso `Order`), você precisa ter mais cuidado. Por exemplo, em `order.getItems()`, retornamos uma cópia (`[...orderItems]`) para evitar que o chamador adicione itens diretamente ao array interno do pedido.
Para aplicações complexas, bibliotecas como Immer podem tornar o trabalho com estruturas aninhadas imutáveis muito mais fácil, mas o princípio central permanece: trate suas entidades como valores imutáveis. Quando uma alteração precisa acontecer, crie um novo valor.
Tratamento de Operações Assíncronas e Persistência
Você pode ter notado que nossas entidades são inteiramente síncronas. Elas não sabem nada sobre bancos de dados ou APIs. Isso é intencional e uma grande força do padrão!
Entidades não devem se salvar. O trabalho de uma entidade é impor regras de negócios. O trabalho de salvar dados em um banco de dados pertence a uma camada diferente de sua aplicação, frequentemente chamada de Camada de Serviço, Camada de Casos de Uso ou Padrão de Repositório.
Veja como eles interagem:
// arquivo: /use-cases/create-user.js
// Este caso de uso depende da fábrica de entidades do usuário e de uma função de acesso ao banco de dados.
export default function makeCreateUser({ makeUser, usersDatabase }) {
return async function createUser(userInfo) {
// 1. Cria uma entidade de domínio válida. Esta etapa valida os dados.
const user = makeUser(userInfo);
// 2. Verifica regras de negócios que exigem dados externos (por exemplo, exclusividade de e-mail)
const exists = await usersDatabase.findByEmail({ email: user.getEmail() });
if (exists) {
throw new Error('Endereço de e-mail já em uso.');
}
// 3. Persiste a entidade. O banco de dados precisa de um objeto simples.
const persisted = await usersDatabase.insert({
id: user.getId(),
firstName: user.getFirstName(),
// ... e assim por diante
});
return persisted;
}
}
Essa separação de preocupações é poderosa:
- A entidade `User` é pura, síncrona e fácil de testar unitariamente.
- O caso de uso `createUser` é responsável pela orquestração e pode ser testado em integração com um banco de dados simulado.
- O módulo `usersDatabase` é responsável pela tecnologia de banco de dados específica e pode ser testado separadamente.
Serialização e Desserialização
Suas entidades, com seus métodos, são objetos ricos. Mas quando você envia dados por uma rede (por exemplo, em uma resposta de API JSON) ou os armazena em um banco de dados, você precisa de uma representação de dados simples. Este processo é chamado de serialização.
Um padrão comum é adicionar um método `toJSON()` ou `toObject()` à sua entidade.
// ... dentro da função makeUser ...
return Object.freeze({
getId: () => id,
// ... outros getters
// Método de serialização
toObject: () => ({
id,
firstName,
lastName,
email: normalizedEmail,
createdAt
// Note que não incluímos o passwordHash
})
});
O processo reverso, pegar dados simples de um banco de dados ou API e transformá-los de volta em uma entidade de domínio rica, é exatamente para que sua função fábrica `makeUser` serve. Esta é a desserialização.
Tipagem com TypeScript ou JSDoc
Embora este padrão funcione perfeitamente em JavaScript puro, adicionar tipos estáticos com TypeScript ou JSDoc o potencializa. Os tipos permitem que você defina formalmente a "forma" de sua entidade, fornecendo excelente preenchimento automático e verificações em tempo de compilação.
// arquivo: /domain/user.ts
// Define a interface pública da entidade
export type User = Readonly<{
getId: () => string;
getFirstName: () => string;
// ... etc
getFullName: () => string;
}>;
// A função fábrica agora retorna o tipo User
export default function buildMakeUser(...) {
return function makeUser(...): User {
// ... implementação
}
}
Os Benefícios Gerais do Padrão de Entidade de Módulo
Ao adotar este padrão, você ganha uma infinidade de benefícios que se acumulam à medida que sua aplicação cresce:
- Fonte Única de Verdade: Regras de negócios e validação de dados são centralizadas e inequívocas. Uma alteração em uma regra é feita em exatamente um lugar.
- Alta Coesão, Baixo Acoplamento: As entidades são autônomas e não dependem de sistemas externos. Isso torna seu codebase modular e fácil de refatorar.
- Testabilidade Suprema: Você pode escrever testes unitários simples e rápidos para sua lógica de negócios mais crítica sem simular o mundo inteiro.
- Experiência do Desenvolvedor Aprimorada: Quando um desenvolvedor precisa trabalhar com um `User`, ele tem uma API clara, previsível e autodocumentada para usar. Chega de adivinhar a forma de objetos simples.
- Uma Base para Escalabilidade: Este padrão oferece um núcleo estável e confiável. À medida que você adiciona mais funcionalidades, frameworks ou componentes de UI, sua lógica de negócios permanece protegida e consistente.
Conclusão: Construa um Núcleo Sólido para Sua Aplicação
Em um mundo de frameworks e bibliotecas em rápida evolução, é fácil esquecer que essas ferramentas são transitórias. Elas mudarão. O que perdura é a lógica central do seu domínio de negócios. Investir tempo para modelar adequadamente este domínio não é apenas um exercício acadêmico; é um dos investimentos de longo prazo mais significativos que você pode fazer na saúde e longevidade de seu software.
O Padrão de Entidade de Módulo JavaScript fornece uma maneira simples, poderosa e nativa de implementar essas ideias. Não requer um framework pesado ou uma configuração complexa. Ele aproveita os recursos fundamentais da linguagem – módulos, funções e closures – para ajudá-lo a construir um núcleo limpo, resiliente e compreensível para sua aplicação. Comece com uma entidade chave em seu próximo projeto. Modele suas propriedades, valide sua criação e dê a ela comportamento. Você estará dando o primeiro passo em direção a uma arquitetura de software mais robusta e profissional.